В данном проекте производится анализ маркетинговых показателей развлекательного приложения.
Заказчик исследования: компания, владеющая приложением Procrastinate Pro+.
Основные задачи:
План работы:
Описание данных:
В трёх датасетах предоставлены данные о пользователях, привлечённых с 1 мая по 27 октября 2019 года:
Импортируем необходимые библиотеки:
import pandas as pd
import os
import numpy as np
from datetime import datetime, timedelta
from matplotlib import pyplot as plt
import seaborn as sns
Загружаем файлы:
if os.path.exists('/datasets/visits_info_short.csv'):
visits = pd.read_csv('/datasets/visits_info_short.csv')
else:
visits = pd.read_csv('/C:/datasets/visits_info_short.csv')
if os.path.exists('/datasets/orders_info_short.csv'):
orders = pd.read_csv('/datasets/orders_info_short.csv')
else:
orders = pd.read_csv('/C:/datasets/orders_info_short.csv')
if os.path.exists('/datasets/costs_info_short.csv'):
costs = pd.read_csv('/datasets/costs_info_short.csv')
else:
costs = pd.read_csv('/C:/datasets/costs_info_short.csv')
Для проверки и знакомства с данными будем использовать собственную функцию:
def my_check_function (dataset):
'''Функция для ознакомления и проверки даных'''
print('Как выглядят случайные 5 строк:')
display(dataset.sample(5, random_state=42))
print()
print('Общая информация о данных, наименования столбцов, типы данных:')
print(dataset.info())
print()
print('Есть ли пропуски, сколько их:')
print(dataset.isna().sum())
print('Доля пропущенных значений:')
print(dataset.isna().mean())
print()
print('Есть ли явные дубликаты, сколько их:')
print(dataset.duplicated().sum())
print('Доля явных дубликатов:')
print(dataset.duplicated().mean())
print()
print('Подробное описание данных:')
display(dataset.describe())
my_check_function(visits)
Как выглядят случайные 5 строк:
| User Id | Region | Device | Channel | Session Start | Session End | |
|---|---|---|---|---|---|---|
| 306198 | 934320811921 | France | Android | organic | 2019-10-24 01:38:48 | 2019-10-24 01:49:19 |
| 85523 | 132341255272 | United States | iPhone | TipTop | 2019-07-25 17:59:53 | 2019-07-25 18:10:05 |
| 12577 | 281310615922 | United States | Android | organic | 2019-05-14 03:47:59 | 2019-05-14 04:48:20 |
| 212675 | 36291923057 | Germany | PC | lambdaMediaAds | 2019-05-12 04:43:46 | 2019-05-12 04:45:14 |
| 109902 | 243524410124 | United States | Android | TipTop | 2019-08-16 06:37:21 | 2019-08-16 07:15:07 |
Общая информация о данных, наименования столбцов, типы данных: <class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 309901 non-null int64 1 Region 309901 non-null object 2 Device 309901 non-null object 3 Channel 309901 non-null object 4 Session Start 309901 non-null object 5 Session End 309901 non-null object dtypes: int64(1), object(5) memory usage: 14.2+ MB None Есть ли пропуски, сколько их: User Id 0 Region 0 Device 0 Channel 0 Session Start 0 Session End 0 dtype: int64 Доля пропущенных значений: User Id 0.0 Region 0.0 Device 0.0 Channel 0.0 Session Start 0.0 Session End 0.0 dtype: float64 Есть ли явные дубликаты, сколько их: 0 Доля явных дубликатов: 0.0 Подробное описание данных:
| User Id | |
|---|---|
| count | 3.099010e+05 |
| mean | 4.997664e+11 |
| std | 2.887899e+11 |
| min | 5.993260e+05 |
| 25% | 2.493691e+11 |
| 50% | 4.989906e+11 |
| 75% | 7.495211e+11 |
| max | 9.999996e+11 |
my_check_function(orders)
Как выглядят случайные 5 строк:
| User Id | Event Dt | Revenue | |
|---|---|---|---|
| 3461 | 500064014414 | 2019-06-14 03:48:02 | 4.99 |
| 18859 | 635733749115 | 2019-09-19 20:08:52 | 4.99 |
| 13095 | 134253736827 | 2019-08-18 08:39:50 | 4.99 |
| 32697 | 822933071092 | 2019-08-09 18:41:47 | 4.99 |
| 2865 | 235894022415 | 2019-06-08 03:31:53 | 4.99 |
Общая информация о данных, наименования столбцов, типы данных: <class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 40212 non-null int64 1 Event Dt 40212 non-null object 2 Revenue 40212 non-null float64 dtypes: float64(1), int64(1), object(1) memory usage: 942.6+ KB None Есть ли пропуски, сколько их: User Id 0 Event Dt 0 Revenue 0 dtype: int64 Доля пропущенных значений: User Id 0.0 Event Dt 0.0 Revenue 0.0 dtype: float64 Есть ли явные дубликаты, сколько их: 0 Доля явных дубликатов: 0.0 Подробное описание данных:
| User Id | Revenue | |
|---|---|---|
| count | 4.021200e+04 | 40212.000000 |
| mean | 4.990295e+11 | 5.370608 |
| std | 2.860937e+11 | 3.454208 |
| min | 5.993260e+05 | 4.990000 |
| 25% | 2.511324e+11 | 4.990000 |
| 50% | 4.982840e+11 | 4.990000 |
| 75% | 7.433327e+11 | 4.990000 |
| max | 9.998954e+11 | 49.990000 |
my_check_function(costs)
Как выглядят случайные 5 строк:
| dt | Channel | costs | |
|---|---|---|---|
| 1591 | 2019-09-29 | WahooNetBanner | 53.40 |
| 943 | 2019-06-13 | AdNonSense | 9.45 |
| 869 | 2019-09-27 | YRabbit | 5.52 |
| 162 | 2019-10-10 | FaceBoom | 176.00 |
| 1271 | 2019-05-12 | OppleCreativeMedia | 3.50 |
Общая информация о данных, наименования столбцов, типы данных: <class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 Channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB None Есть ли пропуски, сколько их: dt 0 Channel 0 costs 0 dtype: int64 Доля пропущенных значений: dt 0.0 Channel 0.0 costs 0.0 dtype: float64 Есть ли явные дубликаты, сколько их: 0 Доля явных дубликатов: 0.0 Подробное описание данных:
| costs | |
|---|---|
| count | 1800.000000 |
| mean | 58.609611 |
| std | 107.740223 |
| min | 0.800000 |
| 25% | 6.495000 |
| 50% | 12.285000 |
| 75% | 33.600000 |
| max | 630.000000 |
На первый взгляд проблем в данных нет (нет ни пропусков, ни явных дубликатов). Единственное, что сейчас заметно из негативного - столбцы с датами и временем записаны в формате object, что не годится для дальнейшей работы.
Заменим формат данных, связанных со временем, во всех имеющихся датасетах: формат object заменим на datetime.
visits['Session Start'] = pd.to_datetime(visits['Session Start'])
visits['Session End'] = pd.to_datetime(visits['Session End'])
orders['Event Dt'] = pd.to_datetime(orders['Event Dt'])
costs['dt'] = pd.to_datetime(costs['dt']).dt.date
Проверяем результат:
dataframes = [visits, orders, costs]
for dataframe in dataframes:
datatypes = dataframe.dtypes
for dtype in datatypes:
print(dtype)
int64 object object object datetime64[ns] datetime64[ns] int64 datetime64[ns] float64 object object float64
Формат изменился. Теперь с датой и временем будет удобно работать.
На всякий случай пройдёмся с проверкой на скрытые дубликаты и аномалии по всем столбцам по порядку. Вспомним, какие у нас есть столбцы:
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 309901 non-null int64 1 Region 309901 non-null object 2 Device 309901 non-null object 3 Channel 309901 non-null object 4 Session Start 309901 non-null datetime64[ns] 5 Session End 309901 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(1), object(3) memory usage: 14.2+ MB
visits['User Id'].duplicated().sum()
159893
visits['User Id'].hist();
visits['User Id'].min()
599326
visits['User Id'].max()
999999563947
В столбце 'User Id' не найдено ничего подозрительного. Здесь есть много дубликатов, но это не удивительно - ведь один и тот же пользователь мог совершать несколько сессий. Сами значения идентификаторов распределены довольно равномерно.
visits['Region'].value_counts()
United States 207327 UK 36419 France 35396 Germany 30759 Name: Region, dtype: int64
В столбце 'Region' тоже всё благополучно (нет ни скрытых дубликатов, ни аномалий).
visits['Device'].value_counts()
iPhone 112603 Android 72590 PC 62686 Mac 62022 Name: Device, dtype: int64
С данными о типах устройств тоже всё в порядке.
visits['Channel'].value_counts()
organic 107760 TipTop 54794 FaceBoom 49022 WahooNetBanner 20465 LeapBob 17013 OppleCreativeMedia 16794 RocketSuperAds 12724 YRabbit 9053 MediaTornado 8878 AdNonSense 6891 lambdaMediaAds 6507 Name: Channel, dtype: int64
В столбце 'Channel' — идентификатор источника перехода — данные без нареканий (а названия даже под стать нашему приложению Procrastinate Pro+. Вспоминается "скажи, кто твой друг, и я скажу, кто ты").
visits['Session Start'].hist();
visits['Session Start'].min()
Timestamp('2019-05-01 00:00:41')
visits['Session Start'].max()
Timestamp('2019-10-31 23:59:23')
visits['Session End'].hist();
visits['Session End'].min()
Timestamp('2019-05-01 00:07:06')
visits['Session End'].max()
Timestamp('2019-11-01 01:38:46')
В столбцах 'Session Start' и 'Session End' хранятся данные о времени и дате начала и конца сессий с 1 мая по 31 октября/1 ноября 2019 года. Аномалий не выявлено.
visits.duplicated(subset=['User Id', 'Session Start']).sum()
0
И последняя проверка основана на предположении, что один и тот же пользователь не мог бы начать больше одной сессии в одно и то же время (это было бы странно). Таких дубликатов нет. С датафреймом visits всё в порядке.
Переходим к проверке следующего датафрейма:
orders.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 40212 non-null int64 1 Event Dt 40212 non-null datetime64[ns] 2 Revenue 40212 non-null float64 dtypes: datetime64[ns](1), float64(1), int64(1) memory usage: 942.6 KB
orders['User Id'].duplicated().sum()
31331
orders['User Id'].hist();
orders['Event Dt'].duplicated().sum()
49
orders['Event Dt'].hist();
orders['Revenue'].value_counts()
4.99 38631 5.99 780 9.99 385 49.99 212 19.99 204 Name: Revenue, dtype: int64
В датафрейме orders тоже не выявлено ничего подозрительного. Переходим к последней проверке:
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 Channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
costs['dt'].hist();
costs['dt'].duplicated().sum()
1620
costs['dt'].value_counts()
2019-05-01 10
2019-08-22 10
2019-08-24 10
2019-08-25 10
2019-08-26 10
..
2019-07-01 10
2019-07-02 10
2019-07-03 10
2019-07-04 10
2019-10-27 10
Name: dt, Length: 180, dtype: int64
costs['Channel'].value_counts()
FaceBoom 180 MediaTornado 180 RocketSuperAds 180 TipTop 180 YRabbit 180 AdNonSense 180 LeapBob 180 OppleCreativeMedia 180 WahooNetBanner 180 lambdaMediaAds 180 Name: Channel, dtype: int64
costs['costs'].hist();
В последнем датафрейме тоже не было выявлено ничего подозрительного.
И последнее, что можно улучшить: названия столбцов сделать в "змеином" регистре, пробелы заменить на нижние подчёркивания.
visits.columns = ['user_id', 'region', 'device', 'channel', 'session_start', 'session_end']
orders.columns = ['user_id', 'event_dt', 'revenue']
costs.columns = ['dt', 'channel', 'costs']
Зададим функции для вычисления значений метрик:
# функция для создания пользовательских профилей
def get_profiles(sessions, orders, ad_costs):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'session_start'])
.groupby('user_id')
.agg(
{
'session_start': 'first',
'channel': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'session_start': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].astype('datetime64[M]')
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# считаем количество уникальных пользователей
# с одинаковыми источником и датой привлечения
new_users = (
profiles.groupby(['dt', 'channel'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# объединяем траты на рекламу и число привлечённых пользователей
ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')
# делим рекламные расходы на число привлечённых пользователей
ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']
# добавляем стоимость привлечения в профили
profiles = profiles.merge(
ad_costs[['dt', 'channel', 'acquisition_cost']],
on=['dt', 'channel'],
how='left',
)
# стоимость привлечения органических пользователей равна нулю
profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)
return profiles
# функция для расчёта удержания
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'session_start']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['session_start'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
# функция для расчёта конверсии
def get_conversion(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# определяем дату и время первой покупки для каждого пользователя
first_purchases = (
purchases.sort_values(by=['user_id', 'event_dt'])
.groupby('user_id')
.agg({'event_dt': 'first'})
.reset_index()
)
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
)
# рассчитываем лайфтайм для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
result = result.fillna(0).cumsum(axis = 1)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# делим каждую «ячейку» в строке на размер когорты
# и получаем conversion rate
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу конверсии
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# для таблицы динамики конверсии убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицу динамики конверсии
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
# функция для расчёта LTV и ROI
def get_ltv(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
)
# рассчитываем лайфтайм пользователя для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция группировки по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
# строим «треугольную» таблицу выручки
result = df.pivot_table(
index=dims, columns='lifetime', values='revenue', aggfunc='sum'
)
# находим сумму выручки с накоплением
result = result.fillna(0).cumsum(axis=1)
# вычисляем размеры когорт
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
# объединяем размеры когорт и таблицу выручки
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# считаем LTV: делим каждую «ячейку» в строке на размер когорты
result = result.div(result['cohort_size'], axis=0)
# исключаем все лайфтаймы, превышающие горизонт анализа
result = result[['cohort_size'] + list(range(horizon_days))]
# восстанавливаем размеры когорт
result['cohort_size'] = cohort_sizes
# собираем датафрейм с данными пользователей и значениями CAC,
# добавляя параметры из dimensions
cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()
# считаем средний CAC по параметрам из dimensions
cac = (
cac.groupby(dims)
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
)
# считаем ROI: делим LTV на CAC
roi = result.div(cac['cac'], axis=0)
# удаляем строки с бесконечным ROI
roi = roi[~roi['cohort_size'].isin([np.inf])]
# восстанавливаем размеры когорт в таблице ROI
roi['cohort_size'] = cohort_sizes
# добавляем CAC в таблицу ROI
roi['cac'] = cac['cac']
# в финальной таблице оставляем размеры когорт, CAC
# и ROI в лайфтаймы, не превышающие горизонт анализа
roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]
# возвращаем таблицы LTV и ROI
return result, roi
# получаем таблицы LTV и ROI
result_grouped, roi_grouped = group_by_dimensions(
result_raw, dimensions, horizon_days
)
# для таблиц динамики убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицы динамики LTV и ROI
result_in_time, roi_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
return (
result_raw, # сырые данные
result_grouped, # таблица LTV
result_in_time, # таблица динамики LTV
roi_grouped, # таблица ROI
roi_in_time, # таблица динамики ROI
)
А также зададим функции для визуализации этих метрик:
# функция для сглаживания фрейма
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
# функция для визуализации удержания
def plot_retention(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
# функция для визуализации удержания
def plot_retention_second_version(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
# функция для визуализации конверсии
def plot_conversion(conversion, conversion_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
# функция для визуализации конверсии
def plot_conversion_second_version(conversion, conversion_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
# функция для визуализации LTV и ROI
def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=7):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 12))
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(2, 3, 1)
ltv.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(2, 3, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(2, 3, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(2, 3, 4)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='black', linestyle='--', label='Уровень окупаемости')
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(2, 3, 5, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='black', linestyle='--', label='Уровень окупаемости')
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
# функция для визуализации LTV и ROI вторая версия
def plot_ltv_roi_second_version(ltv, ltv_history, roi, roi_history, horizon, window=7):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 15))
sns.set_palette("cubehelix_r",11)
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(3, 2, 1)
ltv.T.plot(grid=True, ax=ax1)
# смещаем блок с подписями легенд в сторону
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(3, 2, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
# сдвинем подписи вбок, чтобы они не "наезжали" на содержимое графика
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(3, 2, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(3, 2, 5)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='black', linestyle='--', label='Уровень окупаемости')
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(3, 2, 6, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='black', linestyle='--', label='Уровень окупаемости')
plt.legend(bbox_to_anchor=(1, 1))
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
Все необходимые функции для вычисления значений метрик и их визуализации подготовлены для дальнейшей работы.
Используя функцию get_profiles() из предыдущего раздела, составляем профили пользователей:
profiles = get_profiles(visits, orders, costs)
profiles.head()
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 2019-05-01 | True | 1.088172 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 2019-07-01 | False | 1.107237 |
| 2 | 6085896 | 2019-10-01 09:58:33 | organic | iPhone | France | 2019-10-01 | 2019-10-01 | False | 0.000000 |
| 3 | 22593348 | 2019-08-22 21:35:48 | AdNonSense | PC | Germany | 2019-08-22 | 2019-08-01 | False | 0.988235 |
| 4 | 31989216 | 2019-10-02 00:07:44 | YRabbit | iPhone | United States | 2019-10-02 | 2019-10-01 | False | 0.230769 |
Определяем минимальную и максимальную даты привлечения пользователей:
profiles['dt'].min()
datetime.date(2019, 5, 1)
profiles['dt'].max()
datetime.date(2019, 10, 27)
Профили пользователей составлены.
Названия столбцов оставим на английском, чтобы в дальнейшем было удобно строить графики, не переключая раскладку клавиатуры каждый раз.
# первая маленькая таблица - число клиентов по странам
profiles_region = (profiles.groupby('region', as_index=False)
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False))
# вторая таблица - число платящих клиентов по странам
profiles_region_payers = (profiles.query('payer==True')
.groupby('region', as_index=False)
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False))
# соединяем обе таблицы вместе
table_region = profiles_region.merge(profiles_region_payers, on='region')
table_region.columns = ['region', 'users', 'payers']
# добавляем столбец с долей платящих пользователей
table_region['percentage_of_payers, %'] = table_region['payers'] / table_region['users'] * 100
# округляем дробь до двух знаков после запятой, чтобы в дальнейшем число на графике хорошо читалось
table_region['percentage_of_payers, %'] = table_region['percentage_of_payers, %'].round(2)
table_region
| region | users | payers | percentage_of_payers, % | |
|---|---|---|---|---|
| 0 | United States | 100002 | 6902 | 6.90 |
| 1 | UK | 17575 | 700 | 3.98 |
| 2 | France | 17450 | 663 | 3.80 |
| 3 | Germany | 14981 | 616 | 4.11 |
Строим графики:
Чтобы удобно было сделать выводы, расположим графики рядом, на одной "подложке":
plt.figure(figsize=(20, 12))
# немного увеличим размер подписей на графиках
sns.set(font_scale=1.5)
# первый график
ax1 = plt.subplot(2, 2, 1)
sns.barplot(x='users', y='region', data=table_region, color = '#0a243c')
plt.title('Распределение клиентов по странам')
plt.xlabel('количество клиентов')
plt.ylabel('')
ax1.bar_label(ax1.containers[0], label_type='center', color='white')
# второй график
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
sns.barplot(x='payers', y='region', data=table_region, color = '#0a243c')
plt.title('Распределение платящих клиентов по странам')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
ax2.bar_label(ax2.containers[0], label_type='center', color='white')
# третий график
ax3 = plt.subplot(2, 2, 3, sharey=ax2)
sns.barplot(x='percentage_of_payers, %', y='region', data=table_region, color = '#0a243c')
plt.title('Доля платящих клиентов в каждой стране, %')
plt.xlabel('доля платящих клиентов, %')
plt.ylabel('')
ax3.bar_label(ax3.containers[0], label_type='center', color='white')
plt.tight_layout()
plt.show();
Пользователи приходят в приложение из 4-х стран:
Больше всего платящих пользователей приходит из США (это и самое большое количество - 6902, и самый большой процент - 6.9%).
Интересный момент: разница между США и другими странами
Получается, что по доле платящих пользователей европейские страны не так сильно уступают США (как в общем количестве пользователей и кол-ве платящих пользователей), а Германия и вовсе нарушает "тренд" первых двух графиков и по доле платящих пользователей "обгоняет" Великобританию и Францию.
Для исследования этого вопроса так же:
Строим таблицу:
# первая таблица - число пользователей с разными типами устройств
profiles_device = (profiles.groupby('device', as_index=False)
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False))
# вторая таблица - число платящих пользователей с разными типами устройств
profiles_device_payers = (profiles.query('payer==True')
.groupby('device', as_index=False)
.agg({'user_id':'nunique'}))
# объединение двух таблиц
table_device = profiles_device.merge(profiles_device_payers, on='device')
table_device.columns = ['device', 'users', 'payers']
# добавление нового столбца с долей платящих клиентов
table_device['percentage_of_payers, %'] = table_device['payers'] / table_device['users'] * 100
# округляем дробное число до двух знаков после запятой
table_device['percentage_of_payers, %'] = table_device['percentage_of_payers, %'].round(2)
table_device
| device | users | payers | percentage_of_payers, % | |
|---|---|---|---|---|
| 0 | iPhone | 54479 | 3382 | 6.21 |
| 1 | Android | 35032 | 2050 | 5.85 |
| 2 | PC | 30455 | 1537 | 5.05 |
| 3 | Mac | 30042 | 1912 | 6.36 |
Делаем визуализацию сразу трёх графиков на одной "подложке":
plt.figure(figsize=(20, 12))
# немного увеличим размер подписей на графиках
sns.set(font_scale=1.5)
# первый график
ax1 = plt.subplot(2, 2, 1)
sns.barplot(x='users', y='device', data=table_device, color = '#1E5128')
plt.title('Распределение клиентов с разными типами устройств')
plt.xlabel('количество клиентов')
plt.ylabel('')
ax1.bar_label(ax1.containers[0], label_type='center', color='white')
# второй график
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
sns.barplot(x='payers', y='device', data=table_device, color = '#1E5128')
plt.title('Распределение платящих клиентов с разными типами устройств')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
ax2.bar_label(ax2.containers[0], label_type='center', color='white')
# третий график
ax3 = plt.subplot(2, 2, 3, sharey=ax2)
sns.barplot(x='percentage_of_payers, %', y='device', data=table_device, color = '#1E5128')
plt.title('Доля платящих клиентов с каждым типом устройств, %')
plt.xlabel('доля платящих клиентов, %')
plt.ylabel('')
ax3.bar_label(ax3.containers[0], label_type='center', color='white')
plt.tight_layout()
plt.show();
Клиенты пользуются следующими устройствами:
Платящие пользователи предпочитают:
Для исследования этого вопроса поступим аналогично:
# первая таблица - число пользователей, привлечённых из разных каналов
profiles_channel = (profiles.groupby('channel', as_index=False)
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False))
# вторая таблица - число платящих пользователей, привлечённых из разных каналов
profiles_channel_payers = (profiles.query('payer==True')
.groupby('channel', as_index=False)
.agg({'user_id':'nunique'}))
# объединение двух таблиц
table_channel = profiles_channel.merge(profiles_channel_payers, on='channel')
table_channel.columns = ['channel', 'users', 'payers']
# создание нового столбца с долей платящих пользователей
table_channel['percentage_of_payers, %'] = table_channel['payers'] / table_channel['users'] * 100
# округление дробного числа до двух знаков после запятой
table_channel['percentage_of_payers, %'] = table_channel['percentage_of_payers, %'].round(2)
table_channel
| channel | users | payers | percentage_of_payers, % | |
|---|---|---|---|---|
| 0 | organic | 56439 | 1160 | 2.06 |
| 1 | FaceBoom | 29144 | 3557 | 12.20 |
| 2 | TipTop | 19561 | 1878 | 9.60 |
| 3 | OppleCreativeMedia | 8605 | 233 | 2.71 |
| 4 | LeapBob | 8553 | 262 | 3.06 |
| 5 | WahooNetBanner | 8553 | 453 | 5.30 |
| 6 | RocketSuperAds | 4448 | 352 | 7.91 |
| 7 | MediaTornado | 4364 | 156 | 3.57 |
| 8 | YRabbit | 4312 | 165 | 3.83 |
| 9 | AdNonSense | 3880 | 440 | 11.34 |
| 10 | lambdaMediaAds | 2149 | 225 | 10.47 |
plt.figure(figsize=(20, 12))
# немного увеличим размер подписей на графиках
sns.set(font_scale=1.5)
# первый график
ax1 = plt.subplot(2, 2, 1)
sns.barplot(x='users', y='channel', data=table_channel, color = '#9EB23B')
plt.title('Распределение клиентов, привлечённых из разных каналов')
plt.xlabel('количество клиентов')
plt.ylabel('')
# делаем подписи - цифры у каждой линейки
for i in ax1.containers:
ax1.bar_label(i,)
ax1.margins(x=0.12)
# второй график
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
sns.barplot(x='payers', y='channel', data=table_channel, color = '#9EB23B')
plt.title('Распределение платящих клиентов, привлечённых из разных каналов')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
# делаем подписи - цифры у каждой линейки
for i in ax2.containers:
ax2.bar_label(i,)
ax2.margins(x=0.12)
# третий график
ax3 = plt.subplot(2, 2, 3, sharey=ax2)
sns.barplot(x='percentage_of_payers, %', y='channel', data=table_channel, color = '#9EB23B')
plt.title('Доля платящих клиентов, привлечённых из разных каналов, %')
plt.xlabel('доля платящих клиентов, %')
plt.ylabel('')
ax3.bar_label(ax3.containers[0], label_type='center', color='white')
plt.tight_layout()
plt.show();
Имеется 11 различных источников пользователей (10 рекламных каналов и "органические пользователи")
Наибольшее число платящих пользователей пришли из следующих каналов:
Наибольшая доля платящих пользователей у следующих каналов:
Интересный момент: "органические пользователи" самые многочисленные, но имеют самую низкую долю платящих пользователей. График с долями платящих клиентов выглядит более равномерным, чем график распределения клиентов по каналам. Также важно, что в последнем графике есть другие "лидеры" помимо FaceBoom и TipTop. Похоже, эти небольшие рекламные каналы перспективны (для точности выводов надо будет узнать цену привлечения клиентов оттуда).
sum_m = costs['costs'].sum()
print(f'Общая сумма расходов на маркетинг составляет {int(sum_m)} долларов')
Общая сумма расходов на маркетинг составляет 105497 долларов
channel_costs = (costs.groupby('channel', as_index=False)
.agg({'costs':'sum'})
.sort_values(by='costs', ascending=False)
.reset_index(drop=True))
channel_costs['costs'] = channel_costs['costs'].astype(int)
channel_costs
| channel | costs | |
|---|---|---|
| 0 | TipTop | 54751 |
| 1 | FaceBoom | 32445 |
| 2 | WahooNetBanner | 5151 |
| 3 | AdNonSense | 3911 |
| 4 | OppleCreativeMedia | 2151 |
| 5 | RocketSuperAds | 1833 |
| 6 | LeapBob | 1797 |
| 7 | lambdaMediaAds | 1557 |
| 8 | MediaTornado | 954 |
| 9 | YRabbit | 944 |
plt.figure(figsize= (10, 7))
sns.set(font_scale=1.2)
ax = sns.barplot(x='costs', y='channel',data=channel_costs, color='#9EB23B')
ax.set_title('Рапределение трат по рекламным источникам')
ax.set_xlabel('сумма трат в долларах')
ax.set_ylabel('')
for i in ax.containers:
ax.bar_label(i,)
ax.margins(x=0.12)
plt.show();
Как мы видим, лидерами (с явным отрывом от других источников) являются:
Другие источники получают финансирование на порядок меньше.
Создаём столбцы с неделями и месяцами:
costs['dt'] = pd.to_datetime(costs['dt'])
costs['week'] = costs['dt'].dt.isocalendar().week
costs['month'] = costs['dt'].dt.month
Проверяем результат:
costs.head()
| dt | channel | costs | week | month | |
|---|---|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.3 | 18 | 5 |
| 1 | 2019-05-02 | FaceBoom | 78.1 | 18 | 5 |
| 2 | 2019-05-03 | FaceBoom | 85.8 | 18 | 5 |
| 3 | 2019-05-04 | FaceBoom | 136.4 | 18 | 5 |
| 4 | 2019-05-05 | FaceBoom | 122.1 | 18 | 5 |
Узнаем, в какие недели начинается отсчёт новых месяцев (это нужно будет для графика ниже):
costs.groupby('month').agg({'week':'first'})
| week | |
|---|---|
| month | |
| 5 | 18 |
| 6 | 22 |
| 7 | 27 |
| 8 | 31 |
| 9 | 35 |
| 10 | 40 |
Попробуем визуализировать динамику изменения расходов по неделям и месяцам на одном графике. Сделаем распределение по неделям, а месяцы отметим вертикальными линиями:
plt.figure(figsize=(20, 10))
sns.set(font_scale=1.5)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix",11)
ax=plt.subplot(1, 1, 1)
costs.pivot_table(
index='week',
columns='channel',
values='costs',
aggfunc='sum'
).plot(ax=ax, grid=True, linewidth=3)
plt.axvline(x=22, color='black', linestyle='--')
plt.axvline(x=27, color='black', linestyle='--')
plt.axvline(x=31, color='black', linestyle='--')
plt.axvline(x=35, color='black', linestyle='--')
plt.axvline(x=40, color='black', linestyle='--')
plt.text(19.5,-100, 'май')
plt.text(24.5,-100, 'июнь')
plt.text(28.5,-100, 'июль')
plt.text(32.5,-100, 'август')
plt.text(37,-100, 'сентябрь')
plt.text(41,-100, 'октябрь')
plt.ylabel('сумма расходов в долларах')
plt.xlabel('неделя')
plt.title('Динамика изменения расходов')
plt.tight_layout()
plt.show();
На графике выше не очень равномерно разграничены месяцы, потому что недели иногда приходятся на два месяца сразу. Для более чёткого распределения данных по месяцам сделаем график, состоящий из двух частей (отдельно по неделям и месяцам) на одной "подложке":
plt.figure(figsize=(20, 10))
sns.set(font_scale=1.5)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix",11)
ax1=plt.subplot(1, 2, 1)
costs.pivot_table(
index='week',
columns='channel',
values='costs',
aggfunc='sum'
).plot(ax=ax1, grid=True, linewidth=3)
plt.ylabel('сумма расходов в долларах')
plt.xlabel('недели')
plt.title('Динамика изменения расходов по неделям')
ax2=plt.subplot(1, 2, 2, sharey=ax1)
costs.pivot_table(
index='month',
columns='channel',
values='costs',
aggfunc='sum'
).plot(ax=ax2, grid=True, linewidth=3)
plt.xlabel('месяцы')
plt.title('Динамика изменения расходов по месяцам')
plt.tight_layout()
plt.show();
Расходы на TipTop и FaceBoom в целом имеют тенденцию к увеличению, это два безусловных лидера на протяжении всего периода анализа. Другие каналы привлечения клиентов имеют небольшое финансирование, которое практически не менялось всё время, оставаясь постоянно на низком уровне.
Используя профили пользователей, составим таблицу:
table_cac = (
profiles.groupby('channel', as_index=False)
.agg({'acquisition_cost':'mean'})
.sort_values(by='acquisition_cost', ascending=False)
.reset_index(drop=True)
)
table_cac = table_cac.drop(table_cac.index[10])
table_cac['acquisition_cost'] = table_cac['acquisition_cost'].round(2)
table_cac
| channel | acquisition_cost | |
|---|---|---|
| 0 | TipTop | 2.80 |
| 1 | FaceBoom | 1.11 |
| 2 | AdNonSense | 1.01 |
| 3 | lambdaMediaAds | 0.72 |
| 4 | WahooNetBanner | 0.60 |
| 5 | RocketSuperAds | 0.41 |
| 6 | OppleCreativeMedia | 0.25 |
| 7 | YRabbit | 0.22 |
| 8 | MediaTornado | 0.22 |
| 9 | LeapBob | 0.21 |
Визуализируем данные:
plt.figure(figsize=(10, 7))
sns.set(font_scale=1.2)
ax=sns.barplot(data=table_cac, x='acquisition_cost', y='channel', color='#9EB23B')
plt.xlabel('доллары')
plt.ylabel('')
plt.title('Средняя стоимость привлечения одного пользователя из каждого источника')
plt.bar_label(ax.containers[0], label_type='center', color='white')
plt.show();
Для сравнения рассчитаем средний CAC без разбивки по источникам:
costs['costs'].sum() / profiles['user_id'].nunique()
0.703277825182657
Или ещё проще:
profiles['acquisition_cost'].mean()
0.7032778251827625
Данные совпадают, значит, расчёты верны. Попробуем добавить эту информацию к имеющемуся графику, чтобы визуализировать, какие каналы в какой степени отличаются от среднего показателя по всему проекту.
plt.figure(figsize=(10, 7))
sns.set(font_scale=1.2)
cac = profiles['acquisition_cost'].mean()
ax=sns.barplot(data=table_cac, x='acquisition_cost', y='channel', color='#9EB23B')
plt.xlabel('доллары')
plt.ylabel('')
plt.axvline(x=cac, color='black', linestyle='--')
plt.text(0.75, 9.3, 'средний САС по всем источникам')
plt.title('Средняя стоимость привлечения одного пользователя из каждого источника')
plt.bar_label(ax.containers[0], label_type='center', color='white')
plt.show();
Общая сумма расходов на маркетинг составляет 105497 долларов.
Распределение трат по рекламным источникам неравномерно:
Больше всего финансирования (с явным отрывом от других) получают два рекламных источника:
В другие каналы привлечения клиентов вкладываются суммы на порядок меньше (примерно в диапазоне от 1 до 5 тыс.долларов).
Динамика изменения расходов во времени показывают две тенденции:
Средняя стоимость привлечения одного пользователя различается от источника к источнику:
Поскольку наши данные имеют даты вплоть до конца октября/начала ноября, посмотрим на ситуацию с точки зрения этих последних дат.
Задаём момент анализа:
observation_date = datetime(2019, 11, 1).date()
Задаём горизонт анализа:
horizon_days = 14
Убираем из анализа "органических" пользователей:
profiles_money = profiles.loc[profiles['channel'] != 'organic']
На всякий случай перепроверяем:
len(profiles) - len(profiles_money)
56439
len(profiles.query('channel == "organic"'))
56439
Из данных исключены 56439 строк, это совпадает с количеством клиентов с каналом привлечения 'organic', значит, всё выполнено корректно.
С помощью подготовленных ранее функций получим необходимые данные (расчёты метрик LTV и ROI):
# здесь используем profiles_money, потому что нам нужны именно пользователи, на привлечение которых потрачены деньги
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_money, orders, observation_date, horizon_days)
И построим графики:
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
На графиках мы видим:
При помощи подготовленных функций получаем данные о конверсии:
# здесь используем profiles, а не profiles_money, потому что нам нужны все пользователи
conversion_raw, conversion_grouped, conversion_history = get_conversion(profiles, orders, observation_date, horizon_days)
Строим на их основе графики:
plt.style.use('seaborn-ticks')
plot_conversion(conversion_grouped, conversion_history, horizon_days)
Полученные данные о конверсии говорят нам:
retention_raw, retention_grouped, retention_history = get_retention(profiles, visits, observation_date, horizon_days)
Строим графики удержания:
plt.style.use('seaborn-ticks')
plot_retention(retention_grouped, retention_history, horizon_days)
Графики удержания показывают:
Конверсия и удержание выглядят естественно, и не могут раскрыть причины появления проблем. Чтобы исследовать этот вопрос, посмотрим на ситуацию под разными углами (с разбивкой по разным параметрам).
Задаём условие и получаем нужные данные:
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_money, orders, observation_date, horizon_days, dimensions=dimensions)
Визуализация:
plt.style.use('seaborn-ticks')
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14)
Проверим конверсию и удержание с разбивкой по устройствам.
Получаем данные о конверсии:
dimensions = ['device']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions)
Строим графики конверсии:
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_conversion(conversion_grouped, conversion_history, horizon_days)
Получаем данные об удержании:
dimensions = ['device']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions)
Строим графики удержания:
plt.style.use('seaborn-ticks')
plot_retention(retention_grouped, retention_history, horizon_days)
Задаём новое условие и получаем данные:
dimensions = ['region']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_money, orders, observation_date, horizon_days, dimensions=dimensions)
Строим графики:
plt.style.use('seaborn-ticks')
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14)
Получаем данные о конверсии с разбивкой по странам:
dimensions = ['region']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions)
Визуализируем:
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_conversion(conversion_grouped, conversion_history, horizon_days)
Получаем данные об удержании:
dimensions = ['region']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions)
Делаем визуализацию:
plt.style.use('seaborn-ticks')
plot_retention(retention_grouped, retention_history, horizon_days)
В сумме данные о конверсии и удержании дают нам такой вывод: пользователи из США (напомню, самая многочисленная группа) лучше всех конвертируются в платящих клиентов, но и хуже всех удерживаются. Это заставляет задуматься о том, а нравится ли им продукт или нет ли каких-то проблем с его использованием.
Задаём условие и получаем данные:
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_money, orders, observation_date, horizon_days, dimensions=dimensions)
Визуализируем при помощи второй версии функции plot_ltv_roi (подписи легенд не будут "наезжать" на содержимое графика, а сами графики несколько иначе разместим на "подложке"):
plt.style.use('seaborn-ticks')
plot_ltv_roi_second_version(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14)
по уровню LTV лидерами являются lambdaMediaAds и TipTop, к концу двухнедельного срока они поднимаются до уровня LTV 1.75 и 1.5 соответственно; умеренный уровень к концу аналогичного срока показывают FaceBoom, AdNonSense, RocketSuperAds, WahooNetBanner (LTV в диапазоне 0.8-0.9); более низкий уровень показывают оставшиеся каналы (LTV около 0.5) (первый график)
динамика LTV показывает колебания всех источников, здесь можно выделить самый крупный всплеск в июне у канала lambdaMediaAds, и его же падение в начале сентября (второй график)
третий график показывает сильное увеличение стоимости привлечения пользователей из TipTop (CAC от 1 в мае до 3.5 в октябре), в то же время другие каналы сохраняют одинаковый уровень цены (в диапазоне от 0.25 до 1 и чуть выше) или даже небольшое снижение (RocketSuperAds)
значительная часть каналов преодолевает уровень окупаемости в течение первых 5-6 дней, но три канала не окупают вложенных средств, это: TipTop, FaceBoom, AdNonSense (четвёртый график)
в основном каналы в течение всех месяцев держатся выше уровня окупаемости, можно заметить всплеск в середине лета у канала YRabbit; из негативного можно отметить постоянное нахождение каналов TipTop и FaceBoom ниже уровня окупаемости (пятый график)
Получаем данные о конверсии с разбивкой по каналам:
dimensions = ['channel']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions)
Строим графики:
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix",11)
plot_conversion_second_version(conversion_grouped, conversion_history, horizon_days)
Получаем данные об удержании:
dimensions = ['channel']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions)
Визуализируем:
plt.style.use('seaborn-ticks')
plot_retention_second_version(retention_grouped, retention_history, horizon_days)
Попробуем более детально посмотреть на вопрос, какие же факторы оказывают наибольший негативный эффект на окупаемость рекламы. На данный момент мы выделили следующие аспекты:
Выделим проблемные аспекты в отдельные датафреймы и сравним их длину
problem_device = profiles.query('device == "iPhone" or device == "Mac"')
problem_region = profiles.query('region == "United States"')
problem_channel = profiles.query('channel == "TipTop" or channel == "FaceBoom" or channel == "AdNonSense"')
print(len(problem_device))
print(len(problem_region))
print(len(problem_channel))
84521 100002 52585
Как мы видим, самый многочисленный срез у нас в категории пользователей США. Кстати, вспомним, что в разрезе по устройствам и в разрезе по каналам у нас несколько проблемных вариантов, а в разрезе по странам явная проблемная зона одна. Попробуем более детально сравнить, чем отличается ситуация в США и в Европе.
Выделим профили пользователей из США и из Европы в отдельные датасеты:
profiles_usa = problem_region
profiles_europe = profiles.query('region != "United States"')
print(len(profiles_usa))
print(len(profiles_europe))
100002 50006
Перепроверяем:
len(profiles)
150008
len(profiles_usa) + len(profiles_europe)
150008
Судя по всему, выделили нужные данные без проблем, ничего не потеряли.
Получим необходимые данные и визуализируем их, сначала для США, потом для Европы:
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_usa, orders, observation_date, horizon_days)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_europe, orders, observation_date, horizon_days)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
Теперь то же самое, но с разбивкой по устройствам, сначала в США, потом в Европе. Гипотеза: если мы увидим, что в разрезе по устройствам выделяются какие-то показатели, значит, есть какая-то взаимосвязь, например, проблема в сочетании двух факторов: пользователь из США + у него айфон, и это сочетание даёт проблему. Если же все линии пойдут "дружно" в одном диапазоне, значит, взаимосвязи нет.
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_europe, orders, observation_date, horizon_days,dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
Теперь посмотрим на ситуацию с разбивкой по каналам. Попытаемся увидеть, есть ли взаимосвязь двух факторов "страна + канал рекламы" так же, сначала для США, затем для Европы:
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_usa, orders, observation_date, horizon_days, dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_europe, orders, observation_date, horizon_days,dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
а вот и разгадка: в Европе другие каналы, и отстутствие агрессивной рекламной кампании пошло на пользу бизнесу. Как говорится, "тише едешь, дальше будешь"
можно выделить один не особо удачный канал: AdNonSense - у него выше, чем у других каналов в Европе, стоимость привлечения и он единственный, кто не пересекает уровень окупаемости, и это происходит бОльшую часть времени (но, правда, его динамика имеет положительную тенденцию)
Мы выяснили, что на самом деле категория "страна" зависела от категории "канал", то есть это не у пользователей из США были проблемы, а агрессивная рекламная кампания TipTop и FaceBoom не оправдала надежд. То есть причина - "канал", а "страна" - лишь следствие.
То же самое, получается, было и с категорией "устройство". Видимо, у пользователей из США, на кого была направлена агрессивная рекламная кампания из каналов TipTop и FaceBoom, просто больше айфонов и макбуков, и поэтому эти категории устройств показали "неудачные" показатели окупаемости. Попробуем визуализировать это отличие США и Европы:
usa_device = (profiles_usa.groupby('device', as_index=False)
.agg({'user_id':'nunique'})
.reset_index(drop=True))
usa_device_payers = (profiles_usa.query('payer==True')
.groupby('device', as_index=False)
.agg({'user_id':'nunique'}))
table_usa = usa_device.merge(usa_device_payers, on='device')
table_usa.columns = ['device', 'users', 'payers']
table_usa['percentage_of_payers, %'] = table_usa['payers'] / table_usa['users'] * 100
table_usa['percentage_of_payers, %'] = table_usa['percentage_of_payers, %'].round(2)
europe_device = (profiles_europe.groupby('device', as_index=False)
.agg({'user_id':'nunique'})
.reset_index(drop=True))
europe_device_payers = (profiles_europe.query('payer==True')
.groupby('device', as_index=False)
.agg({'user_id':'nunique'}))
table_europe = europe_device.merge(europe_device_payers, on='device')
table_europe.columns = ['device', 'users', 'payers']
table_europe['percentage_of_payers, %'] = table_europe['payers'] / table_europe['users'] * 100
table_europe['percentage_of_payers, %'] = table_europe['percentage_of_payers, %'].round(2)
plt.figure(figsize=(20, 30))
# немного увеличим размер подписей на графиках
sns.set(font_scale=1.5)
# первый график
ax1 = plt.subplot(6, 2, 1)
sns.barplot(x='users', y='device', data=table_usa, color = '#1E5128')
plt.title('Распределение клиентов с разными типами устройств в США')
plt.xlabel('количество клиентов')
plt.ylabel('')
for i in ax1.containers:
ax1.bar_label(i,)
ax1.margins(x=0.12)
# второй график
ax2 = plt.subplot(6, 2, 2, sharex=ax1)
sns.barplot(x='users', y='device', data=table_europe, color = '#1E5128')
plt.title('Распределение клиентов с разными типами устройств в Европе')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
for i in ax2.containers:
ax2.bar_label(i,)
ax2.margins(x=0.12)
# третий график
ax3 = plt.subplot(6, 2, 3, sharex=ax2)
sns.barplot(x='payers', y='device', data=table_usa, color = '#1E5128')
plt.title('Распределение платящих клиентов с каждым типом устройств в США')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
for i in ax3.containers:
ax3.bar_label(i,)
ax3.margins(x=0.12)
# четвёртый график
ax4 = plt.subplot(6, 2, 4, sharex=ax3)
sns.barplot(x='payers', y='device', data=table_europe, color = '#1E5128')
plt.title('Распределение платящих клиентов с каждым типом устройств в Европе')
plt.xlabel('количество платящих клиентов')
plt.ylabel('')
for i in ax4.containers:
ax4.bar_label(i,)
ax4.margins(x=0.12)
# пятый график
# тут специально вышли из общей оси х, а то совсем не видно никаких столбцов
# чтобы разделить, что это что-то другое, здесь использую другой формат подписи цифр - внутри и белым
ax5 = plt.subplot(6, 2, 5)
sns.barplot(x='percentage_of_payers, %', y='device', data=table_usa, color = '#1E5128')
plt.title('Доля платящих клиентов с каждым типом устройств в США, %')
plt.xlabel('доля платящих клиентов, %')
plt.ylabel('')
ax5.bar_label(ax5.containers[0], label_type='center', color='white')
# шестой график
ax6 = plt.subplot(6, 2, 6, sharex=ax5)
sns.barplot(x='percentage_of_payers, %', y='device', data=table_europe, color = '#1E5128')
plt.title('Доля платящих клиентов с каждым типом устройств в Европе, %')
plt.xlabel('доля платящих клиентов, %')
plt.ylabel('')
ax6.bar_label(ax6.containers[0], label_type='center', color='white')
plt.tight_layout()
plt.show();
Итого: мы выяснили, что из трёх групп проблем (устройство, страна, канал) источником проблем с окупаемостью является канал, а другие категории (устройство, страна) являются лишь следствием.
Можно сделать финальную проверку, выделив отдельно профили, привлечённые с помощью TipTop и FaceBoom, и все остальные профили. Если мы не увидим больше значимых отличий, значит, у нас только одна причина появления проблем с окупаемостью.
Выделяем профили в отдельные датасеты:
profiles_tiptop_faceboom = profiles.query('channel =="TipTop" or channel =="FaceBoom"')
profiles_other_channel = profiles.query('channel !="TipTop" and channel !="FaceBoom"')
Проверяем:
len(profiles_tiptop_faceboom) + len(profiles_other_channel)
150008
len(profiles)
150008
Никакие данные не потерялись.
Получаем нужные данные и строим графики сначала для профилей, привлечённых через TipTop и FaceBoom, а потом для всех остальных:
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_tiptop_faceboom, orders, observation_date, horizon_days, dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14)
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles_other_channel, orders, observation_date, horizon_days, dimensions=dimensions)
plt.style.use('seaborn-ticks')
sns.set_palette("cubehelix")
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14)
1. Окупается ли реклама, направленная на привлечение пользователей в целом?
- Нет, в целом не окупается. Если в бизнес-плане заложено, что реклама должна окупаться к концу двухнедельного периода "жизни" пользователя (то есть дойти до 100% и выше), то фактически она доходит только до 80%.
- Если смотреть в динамике, то в мае и середине июня вложения в рекламу окупались, но всё остальное время реклама не оправдывают вложенных средств, и с каждым месяцем положение постепенно ухудшается. Чтобы прочувствовать "глубину" падения, обратим внимание на цифры: в начале наблюдений окупаемость инвестиций составляла почти 140%, а в конце опустилась до 60%.
2. Какие устройства, страны и рекламные каналы могут оказывать негативное влияние на окупаемость рекламы?
- устройства: негативное влияние на окупаемость могут оказывать в первую очередь iPhone, Mac, во вторую - Android. Уровень окупаемости привлечения пользователей iPhone и Mac находится отметке ROI 0.7 в конце двухнедельного периода, а пользователей Android на 0.9 (что ниже уровня окупаемости 1.0).
- страны: не окупается привлечение пользователей из США. Окупаемость рекламы в европейских странах происходит примерно на 4-6 день "жизни" пользователя, в то время как реклама для привлечения клиентов из США не окупается и в конце двухнедельного периода. В динамике просматривается тенденция к постепенному медленному росту окупаемости привлечения пользователей из европейских стран и снижению уровня ROI для пользователей из США всё ниже уровня окупаемости.
- каналы: TipTop, FaceBoom, AdNonSense не окупают вложенных средств. Значительная часть каналов преодолевает уровень окупаемости в течение первых 5-6 дней, но три канала не окупают вложенных средств, это: TipTop, FaceBoom, AdNonSense. в основном каналы в течение всех месяцев держатся выше уровня окупаемости, при этом можно отметить постоянное нахождение каналов TipTop и FaceBoom ниже уровня окупаемости
Важно: углублённый анализ показал, что из этих трёх факторов (устройства, страны, каналы) один фактор является причиной неокупаемости, а другие факторы - следствием. Главная причина неокупаемости рекламы - это распределение львиной доли бюджета по рекламным каналам TipTop и FaceBoom, а следствие - это то, что в США низкая окупаемость (именно для США, а не для других стран работают эти каналы привлечения), как следствие и то, что у iPhone и Mac низкая окупаемость (просто у бОльшей части пользователей из США как раз iPhone и Mac).
3. Чем могут быть вызваны проблемы окупаемости? Возможные причины обнаруженных проблем и промежуточные рекомендации для рекламного отдела
В целом проблемы окупаемости могут быть вызваны двумя глобальными направлениями:
В данном исследовании были обнаружены проблемы с эффективностью распределения средств для рекламы.
Кажется, пришло время пересмотреть подход к распределению средств на рекламу.
Общая сумма расходов на маркетинг составляет 105497 долларов.
Распределение трат по рекламным источникам неравномерно:
Больше всего финансирования (с явным отрывом от других) получают два рекламных источника:
В другие каналы привлечения клиентов вкладываются суммы на порядок меньше (примерно в диапазоне от 1 до 5 тыс.долларов).
Динамика изменения расходов во времени показывают две тенденции:
Средняя стоимость привлечения одного пользователя различается от источника к источнику:
1. Окупается ли реклама, направленная на привлечение пользователей в целом?
- Нет, в целом не окупается. Если в бизнес-плане заложено, что реклама должна окупаться к концу двухнедельного периода "жизни" пользователя (то есть дойти до 100% и выше), то фактически она доходит только до 80%.
- Если смотреть в динамике, то в мае и середине июня вложения в рекламу окупались, но всё остальное время реклама не оправдывают вложенных средств, и с каждым месяцем положение постепенно ухудшается. Чтобы прочувствовать "глубину" падения, обратим внимание на цифры: в начале наблюдений окупаемость инвестиций составляла почти 140%, а в конце опустилась до 60%.
2. Какие устройства, страны и рекламные каналы могут оказывать негативное влияние на окупаемость рекламы?
- устройства: негативное влияние на окупаемость могут оказывать в первую очередь iPhone, Mac, во вторую - Android. Уровень окупаемости привлечения пользователей iPhone и Mac находится отметке ROI 0.7 в конце двухнедельного периода, а пользователей Android на 0.9 (что ниже уровня окупаемости 1.0).
- страны: не окупается привлечение пользователей из США. Окупаемость рекламы в европейских странах происходит примерно на 4-6 день "жизни" пользователя, в то время как реклама для привлечения клиентов из США не окупается и в конце двухнедельного периода. В динамике просматривается тенденция к постепенному медленному росту окупаемости привлечения пользователей из европейских стран и снижению уровня ROI для пользователей из США всё ниже уровня окупаемости.
- каналы: TipTop, FaceBoom, AdNonSense не окупают вложенных средств. Значительная часть каналов преодолевает уровень окупаемости в течение первых 5-6 дней, но три канала не окупают вложенных средств, это: TipTop, FaceBoom, AdNonSense. в основном каналы в течение всех месяцев держатся выше уровня окупаемости, приэтом можно отметить постоянное нахождение каналов TipTop и FaceBoom ниже уровня окупаемости
Важно: углублённый анализ показал, что из этих трёх факторов (устройства, страны, каналы) один фактор является причиной неокупаемости, а другие факторы - следствием. Главная причина неокупаемости рекламы - это распределение львиной доли бюджета по рекламным каналам TipTop и FaceBoom, а следствие - это то, что в США низкая окупаемость (именно для США, а не для других стран работают эти каналы привлечения), как следствие и то, что у iPhone и Mac низкая окупаемость (просто у бОльшей части пользователей из США как раз iPhone и Mac).
3. Чем могут быть вызваны проблемы окупаемости?
В целом проблемы окупаемости могут быть вызваны двумя глобальными направлениями:
В данном исследовании были обнаружены проблемы с эффективностью распределения средств для рекламы.
Рекомендации:
Кажется, пришло время пересмотреть подход к распределению средств на рекламу.
Какие могут быть варианты дальнейших действий:
Итого на стыке этих доводов мы можем выделить:
Можно попробовать более-менее равномерно вложить средства во все каналы, кроме TipTop, FaceBoom, AdNonSense (которые показывают неокупаемость) и далее ещё раз (например через месяц) проанализировать результат, скорректировать дальнейшие действия. Этот вариант кажется предпочтительным, ведь, кажется, проблема не в самих каналах TipTop и FaceBoom, а в том, что в них вкладывали непомерно много средств, а выше какого-то рубежа, они, видимо, уже "захлёбываются" и не эффективны. Вполне возможно, что и у других каналов при излишнем финансировании была бы аналогичная проблема с окупаемостью.
Спасибо за внимание!
Презентация: https://disk.yandex.ru/i/HJRJ0ctGWMAs_w